Skip to content

gh-160760: asyncio: run newly scheduled callbacks eagerly before polling for I/O - #145149

Open
asvetlov wants to merge 10 commits into
python:mainfrom
asvetlov:eager-loop
Open

asvetlov wants to merge 10 commits into
python:mainfrom
asvetlov:eager-loop

Conversation

@asvetlov

@asvetlov asvetlov commented Feb 23, 2026 •

Copy link
Copy Markdown
Contributor

Run callbacks that are scheduled during an event loop iteration eagerly, within a small budget, instead of always waiting for the next I/O poll. This removes one select()/epoll_wait() call per hop in code that resolves futures or wakes tasks in chains.

  • New asyncio.EventLoopSettings(eager_max_time, eager_max_callbacks) and loop.get_settings() / loop.set_settings(). The defaults are 100 µs and 100 callbacks; eager_max_time=0 turns eager processing off.
  • Eager processing ends early on asyncio.sleep(0), which still guarantees an I/O poll, and on loop.stop(), which keeps its documented "current batch only" behaviour.
  • Callbacks still run in first-in, first-out order. Only the number of I/O polls changes.

Benchmarks (release build, script): a call_soon() chain is 38% faster, Queue ping-pong 25% faster, and a task awaiting a child task 33% faster. Servers with many connections are unchanged. While a long run of callbacks keeps going, timers and I/O can be delayed by up to about 100–200 µs.

A few tests that assumed "one task hop = one I/O poll" were adjusted.

@github-actions

This comment was marked as outdated.

@github-actions github-actions Bot added the stale Stale PR or inactive for long period of time. label May 7, 2026
@asvetlov asvetlov changed the title Implement eager loop runner #160760: Implement eager loop runner Oct 27, 2026
@asvetlov asvetlov added topic-asyncio and removed stale Stale PR or inactive for long period of time. labels Oct 27, 2026
@asvetlov asvetlov changed the title #160760: Implement eager loop runner gh-160760: asyncio: run newly scheduled callbacks eagerly before polling for I/O Oct 27, 2026
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 27, 2026
@read-the-docs-community

read-the-docs-community Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

@asvetlov
asvetlov force-pushed the eager-loop branch 5 times, most recently from f3f2dfe to 2950322 Compare October 28, 2026 14:53
…e polling for I/O

After running the ready callbacks, BaseEventLoop._run_once() now keeps
running the callbacks they schedule, in batches, before it polls for I/O
again.  This saves one I/O poll per hop of a callback chain, for example
when a completed future wakes up a task.

Eager processing is bounded by the new asyncio.EventLoopSettings,
set with loop.set_settings() and read with loop.get_settings():
eager_max_time (default 100 microseconds) and eager_max_callbacks
(default 100).  asyncio.sleep(0) ends eager processing, so that the
task resumes only after the next I/O poll.  When stop() is called
during eager processing, run_forever() runs one more iteration, which
polls for I/O and runs what the loop would have run without eager
processing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
asvetlov and others added 2 commits October 29, 2026 09:17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@asvetlov
asvetlov marked this pull request as ready for review October 29, 2026 10:42
@asvetlov
asvetlov requested a review from 1st1 as a code owner October 29, 2026 10:42
Comment thread Lib/asyncio/base_events.py Outdated
if left > 0:
left = min(left, ntodo)
self._run_handles(left, eager=False)
if self._stopping:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this test is present here.

@asvetlov asvetlov Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The previous iteration didn't process all _ready handlers because of limits. loop.stop() was not called.
  2. During execution of these pending timeouts, one of the callbacks has called loop.stop()
  3. To not break run_until_complete() logic, we should stop the iteration after executing callbacks from this pack and stop. If we remove the check, extra callbacks (timers, I/O) could be called. It doesn't change any pre-existing tests but behaves a little differently than the 'main' version.

Because of this, I've preferred to keep this check and add a test for pinning the behavior.

@YvesDup YvesDup Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank for the explanation, I forgot the previous iteration. Last question: I wonder why the self._eager_state attr is not update with ntodo - nleft before the return statement as you do it in line 2110 and 2129.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, thanks!
The PR keeps a negative (-1 or -2) _eager_state on a purpose.
If we stored a count, run_forever() would see _stopping and _eager_state >= 0 and run the extra final _run_once() (https://github.com/python/cpython/pull/145149/files#diff-7962e1627bc1bfbce13bef0be78d5ef7fc962f99bbc524a199ef489852ca1344R689). That would run callbacks from the next iteration, which breaks a code that relies on existing run_until_complete() behavior.
With negative _eager_state, run_forever() just breaks. The remaining callbacks stay queued and run the next time the loop runs.

@YvesDup YvesDup Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your reply. It was hard for me to understand how self._state_eager can behave sometimes as a state and sometimes as a counter. Everything is clear to me now.

asvetlov and others added 2 commits October 29, 2026 19:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread Lib/asyncio/events.py
if (not isinstance(self.eager_max_time, (int, float))
or isinstance(self.eager_max_time, bool)):
raise TypeError(f"eager_max_time must be an int or float, "
f"got {self.eager_max_time!r}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you check attribute type, it is more appropriate to print the bad type that raises the exception.
I suggest f"got {type(self.eager_max_time).__name__!r}".
Same remark applies to self.eager_max_callbacks line 266

Comment thread Lib/asyncio/events.py


EventLoopSettings.DEFAULT = EventLoopSettings(eager_max_time=1e-4,
eager_max_callbacks=100)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace 100 with EAGER_BATCH_SIZE ?

Comment thread Lib/asyncio/constants.py
# of the eager_max_time deadline. A batch also ends where the callbacks
# scheduled during it begin, so for chains of callbacks the deadline is
# checked after every callback.
EAGER_BATCH_SIZE = 100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does adding the constant EAGER_MAX_TIME = 1e-4 make sense?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants